Lib.php

<?php

namespace Tlf\User;

use Tlf\User\Configurations as C;

class Lib {

    use Throttle;

    /**
     * See Configurations.php for available configs
     *
     */
    public $config = [];

    /** valid symbol characters for a password */
    public string $password_symbols = '~`!@#$%^&*()_-+={[]}|\\:;"\'<,>.?/';
    /** max len should not be changed, bc bcrypt limit is 72 chars */
    public int $max_len = 72;
    public int $min_len = 8;
    public bool $require_num = true;
    public bool $require_symbol = true;
    public bool $require_mix_case = true;
    /** the class of the user to instantiate */
    public string $user_class = '\\Tlf\\User';

    /**
     * Which mail service to use when sending emails to users
     * @NOTE default mail service is PHP_MAIL
     */
    public \Tlf\User\MailService $mail_service = \Tlf\User\MailService::PHP_MAIL;
    /**
     * If $mail_service is set to `case CUSTOM_CALLABLE = 'CUSTOM_CALLABLE';` ... then this callable is used
     */
    public mixed $mail_service_callable = null;


    public \PDO $pdo;

    /**
     * to disable a page just add its page identifier to this array. One of 'login', 'register', 'reset-password', 'logout', or 'terms'
     */
    public $disabled_pages = [];

    /** 
     * This should only be set after a session is validated
     * @key the csrf token name
     * @value true, always true
     */
    public array $valid_sessions = [];
    /**
     *
     * @key the key_prefix
     * @value the actual key (with uniqid)
     */
    public array $latest_csrf = [];

    /**
     * Array of query strings identifiable by key. Generated by LilSql (of LilDb package)
     */
    public array $queries = [];

    /**
     * To cache users loaded by their cookie.
     * array<string cookie, Tlf\User $user>
     */
    protected array $cookie_users = [];

    /**
     * Array of cookies that have been invalidated. 
     *
     * There may be conflicting cookies across instances, so there is some tiny risk of re-loading a user that's already cached in some complex setups. But there should be no security risk, since in the worst-case scenario we just remove a user from cache that should be in the cache.
     */
    static protected array $invalidated_user_cookies = [];

    /**
     * Ensures that a user identified by the given cookie will not be loaded from cache during the current request. (*i.e. this is only in-memory change, nothing persistent*)
     *
     * This affects all instances of \Tlf\User\Lib;
     */
    static public function remove_from_cookie_cache(string $cookie){
        static::$invalidated_user_cookies[] = $cookie;
    }

    public function __construct($pdo){
        $this->pdo = $pdo;
        $this->queries = unserialize(file_get_contents(__DIR__.'/../db/serialized.txt'));
    }

    public function is_post():bool{
        if ($_SERVER['REQUEST_METHOD']=='POST')return true;
        return false;
    }

    public function init_db(){
        $pdo = $this->pdo;
        $errmode = $pdo->getAttribute(\PDO::ATTR_ERRMODE);
        $pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
        $sql = file_get_contents(dirname(__DIR__).'/db/create.sql');
        $pdo->exec($sql);
        $pdo->setAttribute(\PDO::ATTR_ERRMODE, $errmode);

        // if ($pdo->errorCode()!='00000'){
            // print_r($err);
            // throw new \PDOException()
        // }
    }

    /** 
     * Checks if a page is disabled in `$this->disabled_pages` and outputs a message if so
     *
     * @output a message that the page is disabled
     * @return true/false 
     */
    public function page_is_disabled(string $page_id){
        // print_r($this->disabled_pages);
        // var_dump($page_id);
        // exit;
        if (!in_array($page_id, $this->disabled_pages))return false;

        if (!headers_sent()){
            header('HTTP/1.1 403 Forbidden', 403);
        }
        echo "\n<h1>Page Disabled</h1>\n<p>You are not allowed to access this page</p>\n";
        return true;

        // $url = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
        // $parts = explode('/',$url);
        // $size = count($parts);
        // if ($parts[$size-1]=='')$size--;
        // $rel_url = '/'.$parts[$size-1];
        // if (!in_array($rel_url, $this->disabled_pages)){
            // return true;
        // }
//
        // echo "\n<h1>Page Disabled</h1>\n<p>You are not allowed to access this page</p>\n";
//
        // return false;
    }

    /**
     * delete an entry in `role_permission` table
     */
    public function role_deny(string $role, string $permission){
        $stmt = $this->pdo->prepare($this->queries['role.deny']);
        $stmt->execute(['role'=>$role, 'permission'=>$permission]);

    }
    /**
     * Delete all entries with given role from `role_permission` and `user_role` tables
     */
    public function role_delete(string $role){
        $stmt = $this->pdo->prepare($this->queries['role.delete']);
        $stmt->execute(['role'=>$role]);

        // var_dump($stmt->rowCount());
        // print_r($this->pdo->errorInfo());
        // exit;
    }
    /**
     * add entry to `role_permission` table
     */
    public function role_allow(string $role, string $permission){
        $stmt = $this->pdo->prepare($this->queries['role.allow']);
        $stmt->execute(['role'=>$role, 'permission'=>$permission]);

        // var_dump($stmt->rowCount());
        // print_r($this->pdo->errorInfo());
        // exit;
    }

    /**
     * @return true/false whether password meets requirements or not
     */
    public function is_password_valid(string $password){
        $len = strlen($password);
        if ($len < $this->min_len || $len > $this->max_len)return false;

        if ($this->require_num&&!preg_match('/\d/', $password))return false;

        if ($this->require_mix_case&&
            (!preg_match('/[a-z]/', $password) ||
                !preg_match('/[A-Z]/', $password)
            )
        ) return false;

        // echo 'len & digits';
        // exit;

        $symbols = $this->password_symbols; 
        $reg = preg_quote($symbols, '/');
        if ($this->require_symbol&&!preg_match("/[$reg]/", $password))return false;

        return true;
    }

    

    /** 
     * Get an array of users.
     *
     * @param string $role
     * @return array of user rows (or objects if 2nd param is true)
     */
    public function users_with_role(string $role): array {
        $class = $this->user_class;
        //$user = new $class($this->pdo);
        //$user->email = $email;

        $pdo = $this->pdo;
        $stmt = $pdo->prepare($this->queries['user.with_role']);
        $stmt->execute(['role'=>$role]);
        $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC);
        return $rows;
    }

    /** 
     * get a user by their email. User may or may not be in the database/registered/active 
     * @return a user object (always, regardless of user existing in database)
     */
    public function user_from_email(string $email): \Tlf\User {
        $class = $this->user_class;
        $user = new $class($this->pdo);
        $user->email = $email;

        $pdo = $this->pdo;
        $stmt = $pdo->prepare($this->queries['user.from_email']);
        $stmt->execute(['email'=>$email]);
        $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC);
        if (count($rows)!==1)return $user;
        $user->from_row($rows[0]);
        return $user;
    }


    /**
     * Login a user by validating the cookie sent with their request
     * @note for testing set $_COOKIE['taeluf_login'] = $code prior to calling this function
     * @param $cookie optional cookie code to use. else uses $_COOKIE['taeluf_login']
     *
     * @return user object if succesful, false otherwise
     */
    public function user_from_cookie(?string $cookie=null) {

        $code = $cookie;
        if ($code === null)$code = $_COOKIE[\Tlf\User::$cookie_name] ?? null;

        if ($code===null)return false;

        if (isset($this->cookie_users[$code])){
            if (in_array($code,static::$invalidated_user_cookies)){
                unset($this->cookie_users[$code]);
            } else {
                return $this->cookie_users[$code];
            }
        }

        $stmt = $this->pdo->prepare($this->queries['user.from_cookie']);
        $stmt->execute(['code'=>$code]);
        $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC);

        if (count($rows)!==1)return false;
        $row = $rows[0];
        if ($row['is_active']!=true)return false;

        $class = $this->user_class;
        $user = new $class($this->pdo);
        $user->email = $row['email'];
        $user->is_logged_in = true;
        $user->id = $row['id'];

        $this->cookie_users[$code] = $user;

        return $user;
    }



    /**
     * Send email using the configured email service. Default config uses php `mail()`
     */
    public function send_mail(
        string $to, string $subject, string $message, 
        array|string $additional_headers = [], string $additional_params = ""): bool
    {


        if (filter_var($to, FILTER_VALIDATE_EMAIL)===false){
            //$content = 'email '.$to.' is not a valid email.';
            //throw new \Lia\User\Exception(\Lia\User\Exception::ERR_INVALID_EMAIL, $to);
            throw new \Exception("Email cannot send because '$to' is not a valid email address");
        }

        if ($this->config[C::email_from]==null){
            throw new \Exception("Cannot send email because config '".C::email_from."' is null");
        } else if ($this->config[C::web_address] == null){
            throw new \Exception("Cannot send email because config '".C::web_address."' is null");
        }

        $additional_headers['From'] = $this->config[C::email_from];
        $additional_headers['Reply-To'] = $this->config[C::email_from];
        switch ($this->mail_service){

            case \Tlf\User\MailService::PHP_MAIL:
                $did_send_email = mail($to,$subject,$message,$additional_headers,$additional_params);
                if (!$did_send_email){
                    http_response_code(500);
                    echo "<h1>Error: Email failed to send</h1>";
                    echo "<p>An internal error on the server prevented the email from being sent.</p>";
                    throw new \Exception("Email failed to send");
                }
                return $did_send_email;
            case \Tlf\User\MailService::DEBUG_TO_TEXTFILE:

                $content = $message;

                // @NOTE For DEBUG_TO_TEXTFILE mail, email-body-out.txt is always written to the root of the user library.
                file_put_contents(
                    dirname(__DIR__,2).'/email-body-out.txt',
                    $content
                );

                error_log("\n\nFake email to '$to'");

                return true;
            case \Tlf\User\MailService::CUSTOM_CALLABLE:
                if ($this->mail_service_callable == null){
                    http_response_code(500);
                    echo "<h1>Error: Email sending service not configured</h1>";
                    echo "<p>The server did not configure the service to send emails.</p>";
                    throw new \Exception("Mail Service Callable is null. Cannot send mail");
                }

                $did_send = ($this->mail_service_callable)($to,$subject,$message,$additional_headers,$additional_params);
                return $did_send;
            case \Tlf\User\MailService::LIB_PHPMAILER:
                $did_send = \Tlf\User\MailService::send_phpmailer_mail($this->config,$to,$subject,$message,$additional_headers,$additional_params);
                return $did_send;
            default:
                http_response_code(500);
                echo "<h1>Error: Improper Server Configuration</h1>";
                echo "<p>Email is not properly configured to send from this server.</p>";
                throw new \Exception("No valid email service was configured.");
        }

    }
    
    ////////////
    // csrf
    ////////////

    public function make_csrf_code(){
        // this code from symfony csrf package: https://github.com/symfony/security-csrf/blob/5.4/TokenGenerator/UriSafeTokenGenerator.php
        $bytes = random_bytes(64);

        return rtrim(strtr(base64_encode($bytes), '+/', '-_'), '=');
    }

    /**
     *
     *
     * @param $key_prefix string to help identify your csrf token.
     * @param $expiry_minutes number of minutes the token should be valid for
     * @param $url the url path the token should be validated on, like '/some/url/'. If not set, it works on any path
     *
     * @return the csrf key. To load csrf data do `$_SESSION[$csrf_key]`. `$csrf_key` will be like `key_prefix-csrf-uniqid()`
     */
    public function enable_csrf(string $key_prefix='',int $expiry_minutes=60, string $url_path=''){
        $key = $key_prefix.'-csrf-'.uniqid();
        $data = [
            'code'=> $this->make_csrf_code(), 
            'expires_at' => time() + $expiry_minutes * 60,
            'uri' => $url_path,
        ];
        if (session_status()==PHP_SESSION_NONE)session_start();
        if (session_status()!=PHP_SESSION_ACTIVE)throw new \Exception("Failed to start session. Cannot do csrf without session.");
        $_SESSION[$key] = $data;
        $this->latest_csrf[$key_prefix] = $key;

        // error_log('csrf key: '.$key);
        return $key;
    }

    /** 
     * get the key of the csrf data in `$_POST` for the given key
     * @param $key_prefix see csrf_is_valid
     */
    public function get_csrf_post_key(string $key_prefix=''): string {
        $len = strlen($key_prefix) + strlen('-csrf-');
        foreach ($_POST as $key=>$value){
            if (substr($key,0,$len)!=$key_prefix.'-csrf-')continue;
            $post_key = $key;
            // $post_code = $value;
            return $post_key;
            // break;
        }
        return '';
    }

    public function get_csrf_session_key(string $key_prefix=''): string {
        if (isset($this->latest_csrf[$key_prefix]))return $this->latest_csrf[$key_prefix];
        $len = strlen($key_prefix) + strlen('-csrf-');
        foreach ($_SESSION as $key=>$value){
            if (substr($key,0,$len)!=$key_prefix.'-csrf-')continue;
            return $key;
        }
        return '';
    }

    public function get_csrf_session_input(string $key_prefix=''): string {
        $key = $this->get_csrf_session_key($key_prefix);
        $code = $_SESSION[$key]['code'];
        return '<input type="hidden" name="'.$key.'" value="'.$code.'">';
    }

    /**
     * Checks `$_POST` for the csrf token
     *
     * @param $key_prefix the same key prefix you passed to `$this->enable_csrf()`
     * @return true/false
     */
    public function csrf_is_valid(string $key_prefix=''): bool {
        // this attempts to do the checks listed on https://www.taeluf.com/blog/php/security/csrf-validation/

        $post_key = $this->get_csrf_post_key($key_prefix);
        if ($post_key=='')return false;
        $post_code = $_POST[$post_key];
        // because i unset from $_SESSION
        if (isset($this->valid_sessions[$post_key]))return true;

        if (session_status()==PHP_SESSION_NONE)session_start();
        if (session_status()!=PHP_SESSION_ACTIVE)throw new \Exception("Failed to start session. Cannot do csrf without session.");

        if (!isset($_SESSION[$post_key]))return false;
        $session_csrf = $_SESSION[$post_key];
        if ($session_csrf['code'] != $post_code) return false;
        if ($session_csrf['expires_at'] < time()) return false;
        $post_path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);

        if ($session_csrf['uri'] != ''
            &&$session_csrf['uri'] != $post_path
        )return false;
        if (!isset($_SERVER['HTTP_REFERER']))return false;
        $referer_domain = parse_url($_SERVER['HTTP_REFERER'], PHP_URL_HOST);
        // to remove the port (mainly bc of localhost testing)
        $server_host = parse_url($_SERVER['HTTP_HOST'], PHP_URL_HOST);
        if ($server_host==null)$server_host = $_SERVER['HTTP_HOST'];

        if ($referer_domain != $server_host)return false;

        unset($_SESSION[$post_key]);
        $this->valid_sessions[$post_key] = true;
        return true;
    }

    public function security_consent_box(){
        throw new \Exception("Security consent box is disabled.");
            //<label>
                //Your IP address and browser's user agent will be logged with the submitted email address & will be viewable by the owner of the account & website administators.<br>
            //&nbsp;<input type=\"checkbox\" name=\"logs_consent\" required>
                //I consent to security logging<br>
            //</label>
        //";
    }

}